feat: RU/EN i18n with header language switcher - #42
Conversation
- Add i18n.js: a small, dependency-free i18n engine exposing window.PortfolioI18n. Dictionaries are grouped by section (hero.*, nav.*, stars.*, ...), keyed meaningfully rather than key1/key2. Language resolves from localStorage, then navigator.language, falling back to RU (per issue #41), and is applied via data-i18n (textContent), data-i18n-attr (title/aria-label/... attributes), and data-i18n-meta (title/meta content) markers. - Add a language switcher to the topbar, styled like the existing theme toggle (collapsed native <select> behind a round icon button), between the theme toggle and the GitHub link. - Prevent FOUC the same way the theme bootstrap already does: a synchronous <head> script resolves the language before paint and (for any non-English result) hides <body> via a .lang-loading class until i18n.js's translation pass removes it. - Cover all visible static UI text: hero, nav, stars/projects section chrome, open source, experience, about, contact, footer, and aria-labels/meta tags (title, description, OG, Twitter cards). main.js's mobile-menu aria-label now goes through PortfolioI18n instead of hardcoded English so it stays translated after toggling. - Mirror the same data-i18n markers into profile/sync/templates/{stars,projects}.html.j2 so the bot-managed sections stay translated after the next sync; verified the rendered templates stay structurally identical to index.html's marker blocks. - Project/contribution data (repo names, descriptions, dates, counts) stays in English as bot-generated content data, not UI copy — translating that dataset is a larger, separate effort and out of scope here. - Add i18n coverage to tests/site-smoke.test.mjs: dictionary key coverage against every data-i18n/-attr/-meta key in the markup, EN/RU key-set parity, switcher markup/position, FOUC-guard wiring, and a functional applyTranslations/ setLanguage test against a minimal fake DOM. Updated the two existing tests (site-smoke.test.mjs, test_site_sync.py) that pinned the literal "Momentum"/"Selected Work" eyebrow text to match the new data-i18n-wrapped markup. - npm test now also `node --check`s i18n.js. Closes #41 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
72d712c to
2f2308f
Compare
Issue #41 explicitly lists 'proof-строки' (proof rows) in the required translation coverage. The opensource proof-row entries (contribution descriptions and merge status) live outside the PROFILE:PROJECTS bot markers — they are hand-maintained markup, not bot-generated content — so the PR's 'project/contribution data stays English' scope-out does not cover them. Add data-i18n keys + EN/RU dictionary entries for all 5 rows and bump the i18n.js cache-busting version. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
Replace the independently hardcoded en/ru literals in index.html's pre-paint bootstrap script and i18n.js's SUPPORTED/DEFAULT_LANG with a shared <meta name="i18n-languages"> tag, following the same pattern already used for demoscene-base. Prevents the two lists from silently diverging if a language is ever added to one and not the other. Falls back to the current en/ru pair when the meta tag is absent (e.g. i18n.js's vm sandbox in tests/site-smoke.test.mjs has no real document), so existing tests are unaffected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
🔍 Local review (cycle 1) — round f5fce9db-ed2c-403b-884a-3bb67367f3e4Reviewed locally (
Resolution: Fixed the FIX finding in ef5da27 (shared |
🔍 Local review (cycle 1, round 2) — round f0091725-60c7-4560-bd54-68ae6b9ecfb0Reviewed locally (
Resolution: No FIX verdicts this round — this is the final review round for cycle 1. Full test suite green (44 JS + 42 Python). |
📋 Review summary — all cycles
Totals: 1 FIX (resolved), 6 SKIP, 1 HALLUCINATION (refuted). Full test suite green throughout (44 JS + 42 Python). |
EN changes: - hero.availability: 'selected' → 'selectively' (remove ambiguity) - hero.lead: add em-dash for clearer structure - hero.startConversation: 'Start a conversation' → 'Get in touch' - hero.footScroll: 'inspect' → 'explore' - stars/projects: 'original repositories' → 'own repositories' - opensource.eyebrow: 'Proof of Work' → 'Contributions' (avoid crypto connotation) - opensource.row1Desc: describe the contribution, not the repo owner - experience.title: 'Engineering through shipped work' → 'Built, shipped, maintained' - about.copy1: 'boring operations' → 'uneventful operations' - contact: rewrite CTA from imperative to question form RU changes: - Fix 'starred projects' mistranslation: 'в избранном' → 'со звёздами' (4 places) - Fix 'выборочных' (statistical sampling) → 'отдельных' (selective) - Fix 'оригинальные' calque → 'собственные' (own) - Fix 'Звёзды во времени' calque → 'Рост звёзд' - Fix 'Proof of Work' → 'Вклад в open source' - Fix 'Побочный проект' (negative) → describe actual contribution - Fix 'Unix-подобная' (Unix-like) → 'в Unix-стиле' (Unix-friendly) - Fix 'выдерживают продакшен' calque → 'работает в продакшене' - Fix 'Начать разговор' literal → 'Написать мне' - Fix 'Приносите' imperative → question form CTA - Fix 'контрибьюций' jargon → 'сотрудничеству' - Fix 'предсказуемую эксплуатацию' officialese → 'спокойную эксплуатацию' - Fix 'Инженерия через реализованную работу' → 'Создано, выпущено, поддерживается' Also updates inline EN text in index.html and Jinja templates to stay in sync with i18n.js EN dictionary. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
…r eyebrow - Revert about.copy1 RU: 'спокойную' back to 'предсказуемую эксплуатацию' (engineering audience, not casual) - Change opensource.eyebrow RU: 'Вклад в open source' → 'Принятые PR' (the section is about merged PRs, not open-source philosophy) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
Headings and short label phrases don't take a terminal period in either English or Russian typographic convention. Affects: stars.title, projects.title, projects.captionStrong, opensource.title, experience.title, about.title — both EN and RU dictionaries, inline HTML, and Jinja templates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
.availability span styled ALL spans as green dots — both the decorative indicator and the text span got width:7px, height:7px, border-radius:50% and a green background. The text span collapsed into a second green dot, leaving the label text overflowing. Narrow the selector to .availability span:first-child so only the empty decorative <span> becomes the dot. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
…ths) - Add data-i18n attributes to project group titles, card descriptions, stars section-note (split around dynamic values), SVG month labels, and experience timeline-org in both index.html and Jinja templates - Add ~29 new i18n keys to both EN and RU dictionaries: stars.notePrefix/Middle/Suffix, 12 month.* keys, 3 projects.group.* keys, 7 projects.desc.* keys, experience.item2Org - Fix test: regex for fork-stars assertion tolerates span markup - Fix test: filter Jinja template expressions from i18n key collection - Bump i18n.js cache-busting version Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
Drop the section-note disclaimer about 5 fork stars — merge all stars into one total (108) for a cleaner presentation. - Replace notePrefix/noteMiddle/noteSuffix with a single sectionNote key - Use stats.stars_earned (108) instead of latest_total (103) in template - Update currentLabel from 'own repositories' → 'all repositories' - Update SVG desc to match the combined total - Mirror changes in index.html, i18n.js (both EN and RU), and test Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
…оектам' Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
Closes two gaps found while verifying PR #42 against issue #41: - The stars SVG chart's <title>/<desc> (the only screen-reader-accessible description of the chart, via aria-labelledby) stayed hardcoded in English regardless of the selected language. - meta.description/meta.ogDescription baked PR/star/starred counts as dictionary literals, so switching language after a bot-sync run would overwrite the freshly-synced numbers in the HTML with stale ones from i18n.js. Adds a small {placeholder} interpolation layer to applyTranslations via a new data-i18n-vars attribute (JSON on the node), and a readProfileVars() helper that sources meta description counts live from the existing <span data-profile-value> nodes profile/sync/apply_site_fragments.py already keeps current — so there's still one source of truth for those numbers, not two. - i18n.js: interpolate(), readVars(), readProfileVars(); stars.chartTitle/ stars.chartDesc dictionary keys (en/ru); meta.description/ogDescription now use {prs}/{stars}/{starred} placeholders instead of literal numbers. - index.html + profile/sync/templates/stars.html.j2: SVG <title>/<desc> wired with data-i18n-meta + data-i18n-vars, kept structurally identical per the bot-sync marker contract. - tests/site-smoke.test.mjs: 3 new tests covering interpolation, the live profile-value substitution, and chart title/desc markup in both index.html and the template. 47/47 JS + 42/42 Python tests pass. - Cache-busting version bumped for i18n.js. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
Two high-severity findings from Codex adversarial review of PR #42: 1. If i18n.js never runs (blocked by an ad-blocker/CSP, network failure, or a stale/missing deploy artifact), the .lang-loading class added by the <head> bootstrap script for any non-English language was never removed — styles.css's `.lang-loading body { visibility: hidden; }` left the page permanently blank. Since RU is the default language, this meant a full blackout for most visitors on any such failure. Fixed with an independent setTimeout(2000ms) fail-open in the same bootstrap script, trading a possible flash of untranslated English for a page that's never permanently blank. 2. The stars section's copy ('all repositories', current-total swatch, and the new SVG <desc> from the previous commit) all state stats.stars_earned (original + fork stars), but the plotted polyline came from stars-history.json's daily entries, which — per its own documented scope ("Original axisrow repositories; forks excluded") — never include fork stars. The chart's endpoint (103) therefore never matched the number the page said it ended at (108). Since fork stars have no per-day history (GitHub's stargazer-events API doesn't expose one), chart_data() now takes a fork_stars offset applied uniformly to every plotted point, so the line's endpoint always equals stats_earned — the page's 'all repositories' framing becomes accurate rather than aspirational. - index.html: fail-open setTimeout for the lang-loading guard. - profile/sync/generate.py: chart_data(history, fork_stars=0); load_history threads cfg.stats.fork_stars through. - profile/projects.json: _note updated to describe the offset instead of the old (now-fixed) discrepancy. - profile/tests/test_generate.py: 2 new tests (offset applied to every point + ceiling scale, and default/explicit-zero parity with prior behavior); existing endpoint test extended to assert chart.latest_total == stats_earned. - tests/site-smoke.test.mjs: new test pinning the fail-open setTimeout in the bootstrap script. - 48/48 JS + 44/44 Python tests pass. Mutation-checked both fixes by temporarily reverting each and confirming the new tests catch the regression. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
🔍 Local review (cycle 1) — round 161c4200-201c-40ce-b84b-089f670d0578Reviewed locally (
Both fixed in 17ea8b9 with tests + mutation checks; |
Critical finding from Codex adversarial review (cycle 2): index.html now depends on i18n.js (added earlier in this PR), but PUBLIC_FILES in profile/sync/build_pages.py — the allowlist the publish workflow uses to build .pages-dist — never included it. The deployed artifact would have silently omitted the file: production requests a nonexistent script, localization never runs (the language selector stays inert), and for default-RU visitors the page relies entirely on the fail-open setTimeout from the previous commit to avoid staying permanently blank. - profile/sync/build_pages.py: add "i18n.js" to PUBLIC_FILES. - profile/tests/test_build_pages.py: new regression test asserting every local <script src="..."> index.html references is present in PUBLIC_FILES, so a future script addition can't repeat this gap. Mutation-checked by temporarily reverting the allowlist change and confirming the new test fails. - 48/48 JS + 45/45 Python tests pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S8oQHBErmyHAWRedfFifZk
🔍 Local review (cycle 2) — round 63b5852c-1610-4091-b7ce-20743c022839Reviewed locally (
FIX fixed in df1556a with a regression test + mutation check. The projects.json/index.html counter mismatch predates this PR (present on main before any commit here) and is out of scope for this i18n change; flagging separately to the user rather than editing bot-owned config as a drive-by. |
🔍 Local review (cycle 3) — round b98fc0ef-556c-4dfc-a4c9-d23e65b9b1b2Reviewed locally (
Codex companion: approve, no findings. No blocking issues this round — cycle complete. |
📋 Review summary — all cycles
Totals: 4 FIX (all resolved), 2 SKIP, 1 IRRELEVANT. 48/48 JS + 45/45 Python tests pass. CI green. Reviewed locally ( |
Summary
Implements client-side i18n (Russian/English) per #41: a
data-i18n/data-i18n-attr/data-i18n-metaengine (i18n.js), a header languageswitcher styled like the existing theme toggle, and no-FOUC language
resolution using the same pattern as the existing theme bootstrap script.
i18n.js: dependency-free engine exposingwindow.PortfolioI18n.Dictionaries are grouped by section (
hero.*,nav.*,stars.*, …),meaningfully keyed rather than
key1/key2. Language resolves fromlocalStorage→navigator.language→ RU fallback (per the issue), anddocument.documentElement.langupdates on every switch.same collapsed-
<select>-behind-an-icon-button technique as the themecontrol (
#37/#38style), full keyboard support and visible focus-ring.index.htmlships English text inline (unchanged frombefore, and what the smoke tests pin). The
<head>bootstrap scriptresolves the visitor's language before paint exactly like it already does
for the theme; for any non-English result it adds
.lang-loadingto<html>, which hides<body>untili18n.js(loaded at the end of body)finishes its translation pass and removes the class.
experience, about, contact, footer,
aria-labels, and meta tags(
<title>, description, OG, Twitter).main.js's mobile-menuaria-labelnow goes throughPortfolioI18n.translateinstead of ahardcoded English string, so it stays translated after toggling.
data-i18nmarkers intoprofile/sync/templates/{stars,projects}.html.j2so the next daily syncdoesn't silently drop the translation markup; verified the rendered
templates stay structurally byte-identical to
index.html's markerblocks (only bot-generated numbers/dates differ).
dates, counts) stays in English — it's bot-generated content data from
profile/projects.json, not UI copy. Translating that dataset is alarger, separate effort; noting it here as a deliberate scope decision
rather than an oversight.
Tests
tests/site-smoke.test.mjs: dictionary key coverage against everydata-i18n/-attr/-metakey in the markup (index.html + both.j2templates), EN/RU key-set parity, switcher markup/position, FOUC-guard
wiring, and a functional
applyTranslations/setLanguagetest against aminimal fake DOM.
Momentum/Selected Workeyebrow text (tests/site-smoke.test.mjs,profile/tests/test_site_sync.py) to match the newdata-i18n-wrapped markup — same visible text, no other existingassertions changed.
npm testnow alsonode --checksi18n.js.npm test(44/44) andpython3 -m pytest profile/tests/(42/42) pass.dark + light theme, mobile-menu aria-label translation, no console
errors, and the marker-block/template structural match via a local Jinja
render.
Closes #41
🤖 Generated with Claude Code